---
id: unittest
title: 36.1 单元测试
sidebar_label: 36.1 单元测试
---

import useBaseUrl from "@docusaurus/useBaseUrl";

:::tip 视频教程

【[单元测试视频教程](https://www.bilibili.com/video/BV1Qy4y1j7aZ/)】

:::

## 36.1.1 关于单元测试

引用自百度百科：

> 单元测试（unit testing），是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义，一般来说，要根据实际情况去判定其具体含义，如 C 语言中单元指一个函数，Java 里单元指一个类，图形化的软件中可以指一个窗口或一个菜单等。总的来说，单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动，软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。

## 36.1.2 单元测试好处

- **消灭低级错误**

基本的单元测试，可以在系统测试之前，把大部分比较低级的错误都消灭掉，减少系统测试过程中的问题，这样也就减少了系统测试中定位和解决问题的时间成本了。

- **找出潜在的 bug**

某些类型的 bug，靠系统测试是很难找到的。例如一些代码分支，平时 99%的场景基本上都走不到，但一旦走到了，如果没有提前测试好，那么可能就是一个灾难。

- **上线前的保证**

加了新代码，上线前跑一把单元测试，都通过，说明代码可能没有影响到之前的逻辑，这样上线也比较放心。如果之前的单元测试跑不过，那么很有可能新的代码有潜在的问题，赶紧修复去吧。

- **重构代码的机会**

写单元测试的过程中，你可能会顺手把一些 code 重构了，为什么？举例，一些长得非常像的代码，如果每次都要写一堆测试代码去测同样的 code，你会不会抓狂？不测吧，覆盖率又上不去，于是我就会想方设法把待测试的 code 改得尽量的精简，重复代码减少，这样覆盖率上去了，测试也好测了，代码也简洁了。如果没有单元测试和覆盖率的要求的话，坦白说可能一来自己不会发现这些重复的 code，另一方面即使发现了，可能也没有太大的动力去改进。

另外，由于单元测试中，你需要尝试去覆盖一些异常分支，这是系统测试常常走不到的地方，于是就会引起你的一些思考，例如这个异常分支是否真的需要？是否真的会发生？对于一些实际上绝对不会出错的函数，那么我觉得可能异常分支是没必要存在的。

- **重新 review 代码的机会**

写 UT 的过程中，我总是会好好看哪些代码执行到了，哪些代码没有执行到，这其实也是一个 review 自己代码的机会，有些时候，并不是 UT 本身帮我找到 bug，而是回头 review 自己代码的时候发现的。

## 36.1.3 单元测试类型

- 基于 API 接口测试（白盒 + 浅度黑盒测试）
- 基于项目代码测试（深度黑盒测试）

## 36.1.4 主流的单元测试库

- `xUnit`（**最流行的库，推荐**）
- `NUnit`
- `MSTest`

**在本章节，`Furion` 框架使用 `xUnit` 库进行单元测试。**

## 36.2 第一个例子

### 36.2.1 创建 `xUnit` 单元测试项目

<img src={useBaseUrl("img/ut1.png")} />

### 36.2.2 第一个测试方法

```cs {1,7,10}
using Xunit;

namespace TestProject1
{
    public class UnitTest1
    {
        [Fact]
        public void Test1()
        {
            Assert.Equal(2, 1 + 1);
        }
    }
}
```

单元测试实际上是通过普通的类的方法进行模块功能测试，具体测试则是标记了 `[Fact]` 特性的方法，在方法中使用 `Assert` 类提供的静态方法进行 `断言`，`断言` 成功，则测试通过，否则测试不通过。

### 36.2.3 运行测试

在单元测试项目中 `右键` 选择 `运行测试` 并打开 `测试资源管理器` 即可查看测试结果。

<img src={useBaseUrl("img/ut2.png")} />

<img src={useBaseUrl("img/ut3.png")} />

### 36.2.4 多个测试方法测试

<img src={useBaseUrl("img/ut4.png")} />

### 36.2.5 重复/回归测试

后续添加更多测试方法只需在 `测试资源管理器` 点击 `在视图中运行所有测试` 播放按钮即可，如下图

<img src={useBaseUrl("img/ut5.png")} />

## 36.3 集成 `Furion` 强大功能

`Furion` 是跨平台、跨项目的开发框架，支持任意项目类型，包括单元测试项目。

### 36.3.1 安装 `Furion` 包

打开 `Nuget` 程序包控制台，安装 `Furion` 包

<img src={useBaseUrl("img/ut6.png")} />

### 36.3.2 添加 `Startup.cs` 类

在单元测试项目根目录下添加 `Startup.cs` 类，并写下以下代码：

```cs {8,16,18,21-26}
using Furion;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;

// 配置启动类类型，第一个参数是 Startup 类完整限定名，第二个参数是当前项目程序集名称
[assembly: TestFramework("TestProject1.Startup", "TestProject1")]

namespace TestProject1
{
    /// <summary>
    /// 单元测试启动类
    /// </summary>
    /// <remarks>在这里可以使用 Furion 几乎所有功能</remarks>
    public sealed class Startup : XunitTestFramework
    {
        public Startup(IMessageSink messageSink) : base(messageSink)
        {
            // 初始化 IServiceCollection 对象
            var services = Inject.Create();

            // 在这里可以和 .NET Core 一样注册服务了！！！！！！！！！！！！！！

            // 构建 ServiceProvider 对象
            services.Build();
        }
    }
}
```

:::important 特别注意

以下代码是必须的！，`TestFramework` 第一个参数是 `类完全限定名（含命名空间）`，第二个参数是 `单元测试程序集名称`

```cs
[assembly: TestFramework("TestProject1.Startup", "TestProject1")]
```

:::

### 36.3.3 使用 `Furion` 完整功能

`Furion` 是跨平台、跨项目的开发框架，下面在单元测试中演示 `远程请求` 并请求 `https://www.baidu.com` 数据，并测试是否请求成功。

- 在 `Startup.cs` 中注册 `远程请求服务`

```cs {7}
public Startup(IMessageSink messageSink) : base(messageSink)
{
    // 初始化 IServiceCollection 对象
    var services = Inject.Create();

    // 在这里可以和 .NET Core 一样注册服务了！！！！！！！！！！！！！！
    services.AddRemoteRequest();

    // 构建 ServiceProvider 对象
    services.Build();
}
```

- 编写测试方法

```cs
[Fact]
public async Task 测试请求百度()
{
    var rep = await "https://www.baidu.com".GetAsync();
    Assert.True(rep.IsSuccessStatusCode);
}
```

- 查看测试结果

<img src={useBaseUrl("img/ut7.png")} />

很神奇吧！`Furion` 支持任何项目类型，任何平台使用。

## 36.4 带参数的测试方法

上面例子中，测试方法都是没有参数的，有时候需要同一个方法输入多个不同的值进行测试，这时候就需要用到 `[Theory]` 和 `[InlineData]` 特性了。

如，下面测试两个数的和是 `奇数`，测试代码如下：

```cs {1-4}
[Theory]
[InlineData(1, 2)]
[InlineData(3, 4)]
[InlineData(5, 7)]
public void 带参数测试(int i, int j)
{
    Assert.NotEqual(0, (i + j) % 2);
}
```

测试结果：

<img src={useBaseUrl("img/ut8.png")} />

## 36.5 如何进行依赖注入

有些时候，我们需要测试某接口，或者进行依赖注入方式解析服务并调用，这时候就需要用到 `App.GetService<>()` 静态类方式了，如：

### 36.5.1 编写一个 `ICalcService` 接口及实现类

```cs {3,8}
namespace TestProject1.Services
{
    public interface ICalcService
    {
        int Plus(int i, int j);
    }

    public class CalcService : ICalcService, ITransient
    {
        public int Plus(int i, int j)
        {
            return i + j;
        }
    }
}
```

### 36.5.2 在测试类中调用

```cs {9,16,22}
using Furion;
using TestProject1.Services;
using Xunit;

namespace TestProject1
{
    public class UnitTest1
    {
        private readonly ICalcService _calcService;

        /// <summary>
        /// 这里不能通过构造函数注入，而是采用 App.GetService<> 方式
        /// </summary>
        public UnitTest1()
        {
            _calcService = App.GetService<ICalcService>();
        }

        [Fact]
        public void 测试两个数的和()
        {
            Assert.Equal(3, _calcService.Plus(1, 2));
        }
    }
}
```

<img src={useBaseUrl("img/ut9.png")} />

### 36.5.3 输出日志

如果在单元测试中想输出日志，只需要在构造函数注入 `ITestOutputHelper` 即可，如：

```cs {2,10,12,18}
using Xunit;
using Xunit.Abstractions;

namespace TestProject1
{
    public class UnitTest1
    {
        private readonly ITestOutputHelper Output;

        public UnitTest1(ITestOutputHelper tempOutput)
        {
            Output = tempOutput;
        }

        [Fact]
        public void Test_String_Equal()
        {
            Output.WriteLine("哈哈哈哈，我是 Furion");
            Assert.NotEqual("Furion", "Fur");
        }
    }
}
```

<img src={useBaseUrl("img/un3.png")} />

### 36.5.4 关于依赖注入作用域释放问题

有时候我们需要对 `非托管资源` 或需要手动释放的对象进行服务解析并测试，这时候需要构建作用域进行测试，如：

```cs
// 支持异步方法测试
[Fact]
public void 测试数据库()
{
    Scoped.Create((f,s) => {
        var otherService = s.ServiceProvider.GetService<IOtherService>();
        var repository = Db.GetRepository<Person>(s.ServiceProvider);

        var isTrue = repository.Any(u => u.Id > 10);
        Assert.True(isTrue);
    });
}
```

### 36.5.5 测试释放资源

有时候，我们需要测试成功后释放一些不能及时释放的对象，这时，只需要实现 `IDisposable` 接口即可：

```cs {6,14-17}
using System;
using Xunit;

namespace TestProject1
{
    public class UnitTest1 : IDisposable
    {
        [Fact]
        public void Test_String_Equal()
        {
            Assert.NotEqual("Furion", "Fur");
        }

        public void Dispose()
        {
            // 释放你的对象
        }
    }
}
```

## 36.6 `Web` 集成测试

有时候，我们需要在没有 `IIS` 服务器或任何外部事物的情况下测试完整的 `Web` 应用程序，这时候，我们只需要执行以下步骤即可：

- **单元测试添加 `Web` 启动层引用**
- **单元测试层安装 `Microsoft.AspNetCore.TestHost` 包**
- 编写测试代码

```cs {15-18,20-21}
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Hosting;
using WebApplication1;
using Xunit;

namespace TestProject1
{
    public class UnitTest1
    {
        [Fact]
        public async Task Test_Web()
        {
            // 创建一个测试服务
            using var testServer = new TestServer(WebHost.CreateDefaultBuilder()
                                               .Inject()
                                               .UseStartup<Startup>()); // 这里的 Startup 就是你 Web 层的 Startup

            // 创建一个 HttpClient 客户端
            using var httpClient = testServer.CreateClient();

            // 测试 Api
            var result = await httpClient.GetStringAsync("/api/user/1");
            Assert.AreEqual("Furion", result.Name);
        }
    }
}
```

:::tip 小知识

这种方式的好处就是无需在启动中调用 `Inject.Create()` 初始化，每一个测试方法都有独立的生命周期，不会污染全局。

:::

## 36.7 `Assert` 断言

`Assert` 是单元测试判定成功的依据，通常第一个参数为 `期望值`，第二个参数为 `实际值`，对比这两个值是否一致即可判断成功与否。详细的 `Assert` 静态方法可查阅官方库 [Assert 方法](https://github.com/xunit/assert.xunit)

## 36.8 反馈与建议

:::note 与我们交流

给 Furion 提 [Issue](https://gitee.com/dotnetchina/Furion/issues/new?issue)。

:::

---

:::note 了解更多

想了解更多 `单元测试` 知识可查阅 [在 .NET 中测试](https://docs.microsoft.com/zh-cn/dotnet/core/testing/) 章节。

:::
